Ruby & Nix

2019-04-13

Why do you write about this?

As some of you know, I've been using Ruby for well over a decade now, and it's still a language I use almost every day for work or pleasure in some fashion, even if it's a quick calculation in irb.

I regularly see questions in #nixos and the NixOS Discourse from folks that want to run some simple Ruby application that hasn't been packaged yet or work on their Ruby stuff with all the gems they need.

While there is documentation about this in the nixpkgs manual, I thought I'd finally share my usual approach to this to clear things up and save me writing the same answers to every question.

Why do i need Nix?

This is usually the first thing that goes through your mind if you are already used to using Ruby and Bundler in an imperative fashion. Of course you care about what version of Ruby you're using, so you use another package manager for that, something like rvm, chruby, asdf, etc.

After that, you just go into some project, run bundle install, and with a bit of luck everything works more or less.

That's of course, until the project starts using gems that aren't pure Ruby or use some 3rd-party executable. The most popular gems with this issue at the time of writing are things like tzinfo, nokogiri, ffi, pg, sqlite3.

Each of these gems in turn has some prejudice about your environment and may or may not find the required dependencies.

So you start writing installation instructions for each system you want to support, with a few lines for each of their respective package manager. You have apt, pacman and brew and hopefully someone will contribute instructions for other systems because that's all you have experience with.

So, the next logical step is to not only have all these instructions, but also provide a Dockerfile that pulls random stuff from the internets and finally provides the one true environment that your application is supposed to run in.

I mean, who'd ever object to such elegance?

How do you use Nix?

What if we could express all of the above in a declarative fashion that can be used by everyone else coming to the project with a single command, doesn't require containers or causes conflicts because your old project uses PostgreSQL 9.4 but your new one uses 9.10?

It turns out that there is another way that is:

  • Reproducible
  • Cachable
  • Portable
  • Reliable

And that's where I'd like to present the Nix approach to project management. Meet our shell.nix:

let
  pkgs = import (
    fetchTarball {
      url = https://github.com/nixos/nixpkgs-channels/archive/0c0954781e257b8b0dc49341795a2fe7d96945a3.tar.gz;
      sha256 = "05fq11wg8mik4zvfjy2gap59r8n0gfbklsw61r45wlqi7a2zsl0y";
    }
  ) {};
  gems = pkgs.bundlerEnv { name = "my-gems"; gemdir = ./.; };
in pkgs.mkShell { buildInputs = [ gems gems.wrappedRuby ]; }

This may seem intimidating at a first glance for people unfamiliar with Nix, but the principle is simple. We import a snapshot of nixpkgs. Just based on this, the version of Ruby, tzinfo, libxml, libzmq, postgresql, sqlite3, and everything else you need, is already specified and used correctly.

The tedious part is deciding which version of nixpkgs you want, but I usually use the latest unstable with this script:

#!/usr/bin/env bash

set -e

url="https://github.com/nixos/nixpkgs-channels"
channel="${@:-nixpkgs-unstable}"
rev="$(git ls-remote "$url" "$channel" | cut -f1)"
archive="$url/archive/$rev.tar.gz"
sha=$(nix-prefetch-url --unpack "$archive")
cat <<EOF
fetchTarball {
  url = $archive;
  sha256 = "$sha";
}
EOF

This is useful for development because even if you come back in a few years and want to run your tests, it will still build the same and should work without any problems.

If you want to change the Ruby version you're using, you can do so by passing ruby = pkgs.ruby_2_6; to bundlerEnv. Otherwise it'll always use the Ruby marked as most stable in nixpkgs, which might lag one version behind.

What about applications?

We covered developing any kind of Ruby library or application above. A common question is "how do i run application X on NixOS and/or via Nix".

Answering this may not be hard, but you have to know where to look, and that's the main reason I'm writing this post, so it might pop up in your searches and save you some time.

To run any kind of Ruby application, the first thing you want to do is write a Gemfile for it (if it's on rubygems, we'll cover other cases later):

source 'https://rubygems.org' do
  gem 't'
end

After this, run the following command:

$ nix-shell -p bundler bundix --run 'bundle lock && bundix'

This will generate two files: Gemfile.lock and gemset.nix.

Finally we need the real core of this, the default.nix:

{ bundlerApp }:
bundlerApp {
  pname = "t";
  gemdir = ./.;
  exes = [ "t" ];
}

That is all.

Now we can try to build and run it:

$ nix-build -E '(import <nixpkgs> {}).callPackage ./. {}'
these derivations will be built:
  /nix/store/smipql38q4vyhg0ba1bfn5fgp5wav9ry-t-3.1.0.drv
  ...
building '/nix/store/smipql38q4vyhg0ba1bfn5fgp5wav9ry-t-3.1.0.drv'...
...
/nix/store/1x3m7rmfzcg2c6x3mwax7qppq157c6mn-t-3.1.0

The last line is the location where our new t command is installed. We also got a new symlink in the current directory called result, that points there.

So we try to run this (fairly old) application:

$ ./result/bin/t authorize
Welcome! Before you can use t, you'll first need to register an
application with Twitter. Just follow the steps below:
  1. Sign in to the Twitter Application Management site and click
     "Create New App".
  2. Complete the required fields and submit the form.
     Note: Your application must have a unique name.
  3. Go to the Permissions tab of your application, and change the
     Access setting to "Read, Write and Access direct messages".
  4. Go to the Keys and Access Tokens tab to view the consumer key
     and secret which you'll need to copy and paste below when
     prompted.

Press [Enter] to open the Twitter Developer site.

Enter your API key: 66616b6520617069206b6579
Enter your API secret: 6f626e6f78696f75732066616b652061706920736563726574
Traceback (most recent call last):
    7: from ./result/bin/t:18:in `<main>'
    6: from ./result/bin/t:18:in `load'
    5: from /nix/store/xy3g0pv6f7j8j217dnhxprs0hx7gfqjk-t-3.1.0/lib/ruby/gems/2.5.0/gems/t-3.1.0/bin/t:20:in `<top (required)>'
    4: from /nix/store/bp0i1lys4sypc3x7jjnnn774sqpy82aa-ruby2.5.5-thor-0.20.3/lib/ruby/gems/2.5.0/gems/thor-0.20.3/lib/thor/base.rb:466:in `start'
    3: from /nix/store/bp0i1lys4sypc3x7jjnnn774sqpy82aa-ruby2.5.5-thor-0.20.3/lib/ruby/gems/2.5.0/gems/thor-0.20.3/lib/thor.rb:387:in `dispatch'
    2: from /nix/store/bp0i1lys4sypc3x7jjnnn774sqpy82aa-ruby2.5.5-thor-0.20.3/lib/ruby/gems/2.5.0/gems/thor-0.20.3/lib/thor/invocation.rb:126:in `invoke_command'
    1: from /nix/store/bp0i1lys4sypc3x7jjnnn774sqpy82aa-ruby2.5.5-thor-0.20.3/lib/ruby/gems/2.5.0/gems/thor-0.20.3/lib/thor/command.rb:27:in `run'
/nix/store/4x9z03ahnmi85jsh1zgayg679pagcrhx-ruby2.5.5-t-3.1.0/lib/ruby/gems/2.5.0/gems/t-3.1.0/lib/t/cli.rb:82:in `authorize': uninitialized constant Twitter::REST::Client::BASE_URL (NameError)

(don't mind my carefully chosen fake credentials ;)

So we check the issues for this gem and find out that it's indeed a bit ancient and lots of people have this problem, and the solution is to pin the twitter dependency to an older version. So let's do that quickly by editing the Gemfile:

source 'https://rubygems.org' do
  gem 't'
  gem 'twitter', '~> 6.1.0'
end

Now run:

$ rm gemset.nix Gemfile.lock
$ nix-shell -p bundler bundix --run 'bundle lock && bundix'

and finally build again:

$ nix-build -E '(import <nixpkgs> {}).callPackage ./. {}'

Et voilà! t finally works again!